Skip to content

Fix SaveFileAsync keeping old bytes when it overwrites a longer file - #84

Merged
kostas-jonauskas merged 1 commit into
mainfrom
fix-save-file-truncation
Nov 9, 2026
Merged

kostas-jonauskas merged 1 commit into
mainfrom
fix-save-file-truncation

Conversation

@tomasr78

@tomasr78 tomasr78 commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

What

  • SaveFileAsync and SaveFilesAsync now replace an existing file instead of writing over its start. The private SaveFileAsync(Uri, string) that every overload goes through opens the target with FileMode.Create. It used FileInfo.OpenWrite. Access and sharing stay as they were (FileAccess.Write, FileShare.None).
  • SaveFilesAsync(outputDirectory) creates the folder if it does not exist. An empty path still means the current directory.
  • New test project ConvertApi.Tests (NUnit 4.6.1, as in the other ConvertAPI .NET repos), added to ConvertApi.sln. The repository had no tests, so the build workflow now runs them between build and pack, and the comments in build.yml and RELEASING.md that said there is no test suite are updated.
  • CHANGELOG.md: an [Unreleased] section.

The public API does not change.

Why

FileInfo.OpenWrite opens with FileMode.OpenOrCreate, which does not truncate. When the path already holds a longer file, its trailing bytes stay after the new content and the saved file is corrupt. That happens whenever a program saves where an earlier, larger result is, for example when it runs again after the input changed. The code has been like this since the first commit (657279b, 2018), so every released version has the bug. No issue tracked it.

Verified on 2026-11-07 against staging with ConvertApi 3.2.2: a 146,734-byte DOCX to PDF result saved over a 400,000-byte file left a 400,000-byte file.

SaveFilesAsync threw DirectoryNotFoundException when the output folder did not exist, so copied code failed on its first run. That is why the C# snippets on convertapi.com now create the folder themselves.

Compatibility

  • Code that calls Directory.CreateDirectory before SaveFilesAsync, as the convertapi.com snippets now do and the README snippets do in README: code that runs as copied, a quick start and C# tasks; NuGet packs the same README #83, still works: creating a folder that exists does nothing. Those snippets should keep the line, because older SDK versions stay in use.
  • SaveFilesAsync("") still saves to the current directory. A test covers it.
  • A folder argument that is an existing file now fails before any download, with IOException: The file '...' already exists. It used to fail after the download with DirectoryNotFoundException, which is a subclass of IOException.
  • A folder argument that names a file that does not exist yet, such as SaveFilesAsync("out.pdf"), now creates a folder named out.pdf instead of throwing.
  • SaveFileAsync(fileName) still needs the file's folder to exist, as File.WriteAllBytes does.

How it was tested

  • New unit tests. Run against the original ConvertApiExtension.cs first, 3 of the 4 fail there (Expected: 146734 But was: 400000, and DirectoryNotFoundException for the missing folder); the 4th guards the empty-path behaviour, which did not change. With this change all 4 pass:
    • SaveFileAsync_ReplacesALongerExistingFile: 146,734 bytes over 400,000. Checks the length, the returned FileInfo and every byte.
    • SaveFilesAsync_ReplacesLongerExistingFiles: two result files over longer files of the same names.
    • SaveFilesAsync_CreatesAMissingFolder: a nested folder that does not exist.
    • SaveFilesAsync_EmptyFolderMeansTheCurrentDirectory.
  • The tests serve the result files from a fake IConvertApiHttpClient, so they need no network and no API token.
  • The CI steps locally (.NET SDK 10.0.401, macOS): dotnet restore ConvertApi.sln, dotnet build ConvertApi.sln -c Release --no-restore -p:ContinuousIntegrationBuild=true, dotnet test ConvertApi.Tests/ConvertApi.Tests.csproj -c Release --no-build, dotnet pack ConvertApi/ConvertApi.csproj. 0 errors, only the 3 existing warnings in Examples, 4 of 4 tests passed, pack succeeded.
  • One real conversion against staging (ConvertApi.ApiBaseUri = "https://stag-v2.convertapi.com") with this branch's library: Examples/TestFiles/test.docx to PDF gave a 146,734-byte test.pdf.
    • response.SaveFileAsync over a 400,000-byte file: 146,734 bytes on disk and in the returned FileInfo, equal to the API's FileSize, starting with %PDF- and ending with %%EOF.
    • response.SaveFilesAsync into a folder that held a longer test.pdf: 146,734 bytes, the same bytes.
    • response.SaveFilesAsync into missing/nested: the folder was created and the file saved.
    • A folder argument that is an existing file: IOException: The file '<work>/single.pdf' already exists.
    • The result and the uploaded input were deleted from the server afterwards.

Release

Not merged and not released. This belongs in the planned 3.3.0 release (first-run fixes and NuGet search metadata). Other 3.3.0 items, such as checking the download status in AsStreamAsync and clearer 401 and 403 messages, are not in this PR.

The CHANGELOG.md hunk will conflict with #83, which also adds an [Unreleased] section. Whichever merges second keeps both entries under one heading.

🤖 Generated with Claude Code

SaveFileAsync(Uri, string), which every SaveFileAsync and SaveFilesAsync
overload goes through, opened the target with FileInfo.OpenWrite. That is
FileMode.OpenOrCreate, which does not truncate, so a result saved over a
longer file kept that file's trailing bytes: a 146,734-byte PDF saved over
a 400,000-byte file stayed 400,000 bytes. It now opens the file with
FileMode.Create, with the same access and sharing as before.

SaveFilesAsync(outputDirectory) also creates the folder when it is
missing, instead of throwing DirectoryNotFoundException. An empty path
still means the current directory.

Adds ConvertApi.Tests (NUnit), the repository's first test project. The
tests serve result files from a fake IConvertApiHttpClient, so they need
no network and no token. The build workflow runs them before the pack.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings November 9, 2026 08:06

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

No active review issues block the file-save overwrite fix and output-folder handling changes.

No open issues 🎉

What changed in this PR

Fixes file-save corruption in the ConvertAPI .NET SDK when a downloaded result overwrites a larger file, without changing the public API.

Changes:

  • Truncates existing destination files and creates missing folders for SaveFilesAsync, preserving empty-path behavior.
  • Adds four network-free regression tests covering overwrites and output-folder handling.
  • Runs tests in CI and updates release documentation and the changelog.
File Description
RELEASING.md Updates the merge-check documentation to include unit tests.
ConvertApi/​ConvertApiExtension.cs Truncates overwritten files, creates output folders, and documents save behavior.
ConvertApi.Tests/​SaveFileTests.cs Adds four regression tests using a fake HTTP client.
ConvertApi.Tests/​ConvertApi.Tests.csproj Defines the NUnit test project and its dependencies.
ConvertApi.sln Includes the test project in solution builds.
CHANGELOG.md Records the overwrite fix, folder creation, and CI tests.
.github/​workflows/​build.yml Runs unit tests between build and pack.

Review effort: Balanced

@kostas-jonauskas
kostas-jonauskas merged commit d0db102 into main Nov 9, 2026
2 checks passed
@kostas-jonauskas
kostas-jonauskas deleted the fix-save-file-truncation branch November 9, 2026 08:18
tomasr78 added a commit that referenced this pull request Nov 9, 2026
Brings in #84: SaveFileAsync replaces a longer file, SaveFilesAsync creates its folder, and unit tests
run in the build check. The only conflict was CHANGELOG.md, where both branches opened [Unreleased]. It
is now one section: #84's Fixed entry, then one Changed list with #84's entries first and this branch's
after them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants